Claude Chrome Testing Workflow Copy
Documentation for Developers
Claude + Chrome: Visual Testing Workflow
The Problem
Claude runs inside a lightweight Linux VM. Your Chrome browser runs on your Mac. There is no network bridge between them. This means:
- Any server Claude starts (e.g.,
python3 -m http.server 8765) listens inside the VM only localhost:8765or127.0.0.1:8765from your Chrome will show "connection refused"- There is no way to expose a VM port to your local network
This is not a bug — it's a security boundary by design. The VM is sandboxed.
The Solution: Chrome JavaScript Injection
Claude has access to your Chrome browser via the "Claude in Chrome" extension. This extension provides tools to:
- Execute arbitrary JavaScript in any open tab
- Take screenshots of tabs
- Read page content and accessibility trees
- Click, type, scroll — full browser automation
The key insight: we don't need to serve files. We inject code directly into an existing page.
How It Works (Step by Step)
Step 1: Open a "Host" Page
Navigate to a simple, publicly reachable website. We use https://example.com/ because:
- It always loads (it's maintained by IANA)
- It has minimal content (easy to replace entirely)
- It's HTTPS, so Canvas and other APIs work normally
- It's not localhost, so Chrome doesn't block it
Step 2: Replace the Page with document.write()
Using the Chrome JavaScript tool, we execute:
document.write("<html>...our entire HTML structure...</html>");
This completely replaces example.com's DOM with our custom page. The CSS, canvas element, layout — everything gets injected in this single call.
Important: The HTML must be properly escaped for JavaScript string context. Single quotes in CSS (like font-family: 'Segoe UI') will break if the outer string uses single quotes. Use JSON-style double-quote escaping or template strings.
Step 3: Inject Large Scripts via Base64 Chunking
The Chrome JS tool has a practical size limit per call (~4-8KB of text). Our VittoEngine bundle is ~21KB minified. To handle this:
Working Approach: Text-Based Chunking
- Encode the entire engine JS as base64 (produces ~28KB of base64 text)
- Split the base64 TEXT string into ~7000-char chunks (not binary chunks — this is critical)
- Inject each chunk sequentially, accumulating on a window variable:
// First chunk
window._B = "SGVsbG8gV29ybGQ...first 7000 chars of base64...";
// Subsequent chunks
window._B += "...next 7000 chars of base64...";
window._B += "...next 7000 chars of base64...";
// ... repeat until all chunks loaded (typically 4 chunks total)
- Decode and inject as a script element:
var code = atob(window._B);
var s = document.createElement('script');
s.setAttribute('data-vitto', 'v2');
s.textContent = code;
document.head.appendChild(s);
CRITICAL: Do NOT split the binary file first then base64 each chunk separately. That creates padded chunks that corrupt when concatenated. Base64 the entire bundle first, then split the resulting base64 text.
This produces window.VittoEngine global with all exports (VittoRenderer, MOOD_DEFINITIONS, etc.).
Step 4: Why Script Element Injection?
Compared to new Function(atob(...)):
- Simpler, no explicit
returnstatement needed - Creates proper global scope for the IIFE
- Avoids evaluation context issues in Chrome extension
- The IIFE naturally defines
window.VittoEngine
Step 5: Inject the UI Script
The UI harness code (mood buttons, sliders, inspector) is smaller (~9KB) and can be injected in 2-3 Chrome JS tool calls directly as executable JavaScript. No base64 needed — just split at natural boundaries (e.g., after mood grid setup, after slider creation, after inspector).
All references to the renderer and state must be stored on window.* (e.g., window._renderer, window._sliderEls) since each Chrome JS tool call runs in a separate evaluation context.
The harness includes:
- 14 mood buttons with emoji labels
- 27 interactive property sliders with live values
- "Copy as JSON" button for exporting custom moods
- "Reset to Mood Preset" button
- Mouse tracking for gaze following
- Current mood label display
- Behavior toggles (breathing, saccades, idle behaviors)
Step 6: Take Screenshots and Interact
Once everything is injected:
- Screenshots capture the live canvas rendering
- JS tool calls can change moods:
window._renderer.setMood('cheerful') - Zoom regions can inspect specific areas of the canvas
- Sliders update properties live with visual feedback
Practical Gotchas
Chrome Throttles Background Tabs
When a tab isn't in the foreground, Chrome throttles requestAnimationFrame to ~1fps. This means:
- Mood transitions that normally take 400ms can take 3-5 seconds
- To work around this, you can force-complete transitions by directly setting state values:
var target = VittoEngine.MOOD_DEFINITIONS['sleepy'];
var s = window._renderer.getState();
s.tT = 1.0; // Skip transition
for (var k of VittoEngine.MOOD_NUMERIC_KEYS) {
if (target[k] !== undefined) s[k] = target[k];
}
s.bg = [...target.bg];
Size Limits per JS Call
Keep each Chrome JS tool call under ~4000 characters of JavaScript. For larger payloads:
- Use base64 chunking (as described above) — split the base64 TEXT, not binary
- Or split logical code sections across multiple calls
- Store shared state on
window.*so subsequent calls can access it
The Tab Persists Until Navigated Away
Once injected, the page stays alive as long as the tab isn't refreshed or navigated. You CAN interact with it from your side too (click mood buttons, drag sliders, move mouse over the canvas).
However: If you refresh the tab, everything disappears (it goes back to example.com). The injection is purely in-memory.
What YOU See vs What Claude Sees
| You (Mac) | Claude (VM) | |
|---|---|---|
localhost:8765 |
Connection refused | Would work inside VM only |
example.com tab |
Vitto harness (after injection) | Can screenshot + interact |
| Dev harness HTML file | Opens locally in your browser | Can't serve to your browser |
Setup Latency & Performance
The Chrome injection process requires 4+ separate JS tool calls just for base64 chunks. This causes setup latency:
- Plan 30-60 seconds for initial setup (build + chunking + injection)
- Subsequent mood changes and property adjustments are much faster
- Future improvements could include:
- Data URL approach (fewer chunks)
- Smaller bootstrap loader
- Pre-minified bundles with even tighter size
This is a known pain point for rapid iteration. Accept the overhead for visual verification, but for tight design-test loops, prefer local testing with the HTML file.
Preparing the Engine for Injection
Before a Chrome testing session, the engine must be built:
# From the monorepo root (your machine or Claude's VM)
cd /path/to/gspace1
npx esbuild libs/libs-anim/vitto-avatar/src/lib/engine/index.ts \
--bundle --format=iife --global-name=VittoEngine \
--outfile=dist/libs/libs-anim/vitto-avatar/engine/vitto-engine.iife.js \
--minify --sourcemap
The output should be ~21KB minified IIFE.
Then to prepare base64 chunks:
# With Python for accurate ~7000-char chunks:
python3 -c "
import base64
with open('dist/libs/libs-anim/vitto-avatar/engine/vitto-engine.iife.js', 'rb') as f:
encoded = base64.b64encode(f.read()).decode()
chunk_size = 7000
for i in range(0, len(encoded), chunk_size):
chunk = encoded[i:i+chunk_size]
with open(f'/tmp/b64chunk_{i // chunk_size}.txt', 'w') as out:
out.write(chunk)
print(f'Chunk {i // chunk_size}: {len(chunk)} chars')
print(f'Total base64 length: {len(encoded)}')
"
This produces typically 4 chunks of ~7000 chars each from the ~28KB base64 string.
For Future Sessions: Quick Reference
When Claude needs to visually test the Vitto avatar in Chrome:
tabs_context_mcp— find or create a tab- Navigate to
example.com(or reuse an existing injection tab) - Inject HTML structure via
document.write() - Build engine if not built:
npx esbuild ... - Base64 encode the entire bundle first
- Split base64 TEXT string into ~7000-char chunks (typically 4 chunks)
- Inject chunks sequentially onto
window._Bwith += concatenation - Decode and inject as script element:
atob(window._B)→ create script → append to head - Inject UI harness script in 2-3 parts
- Take screenshots, change moods, adjust sliders, iterate
Idle Behaviors
The engine includes noise-driven idle animations that make Vitto feel alive even without mood changes:
- Ear twitches
- Head bob
- Whisker flutter
- Subtle breathing variations
- Blink rate modulation
These are controlled by the enableIdleBehaviors config flag and can be toggled via the "Idle Behaviors" checkbox in the UI harness.
Your Local Workflow (Paco)
You don't need any of this! You can:
- Open
vitto animation/vitto-dev-harness.htmldirectly in your browser - It loads the engine from
../gspace1/dist/libs/libs-anim/vitto-avatar/engine/vitto-engine.iife.js - Full 60fps, mouse tracking, all controls work natively
- Prerequisite: Run the esbuild command first so the bundle exists at that path
Or once we wire up the Angular route:
yarn serve:gspace1- Navigate to
localhost:6200/dev/vitto-avatar(or whatever route we create) - Full Angular integration with live reload
Why This Matters
This workflow lets both of us iterate on the same codebase:
- You test locally at 60fps with full interactivity
- Claude tests via Chrome injection with screenshots
- Zero code duplication — both workflows consume the same esbuild bundle
- DRY principle upheld — the engine source lives in one place only
Debugging Tips
If the injection fails:
- Check bundle size:
ls -lh dist/libs/libs-anim/vitto-avatar/engine/vitto-engine.iife.js - Verify base64 length matches Python output
- Check that each chunk injection completes before the next
- If decoder fails, verify
window._Bcontains all concatenated chunks - Check Chrome console for decode errors (via
read_console_messages)
If rendering is slow:
- Remember background tabs are throttled to ~1fps — this is normal
- Use force-complete transition method to speed up visual feedback
- For rapid iteration, test locally in the HTML file at 60fps first